// Copyright 2012 Square, Inc.
package com.squareup.timessquare;

import static java.util.Calendar.DATE;
import static java.util.Calendar.DAY_OF_MONTH;
import static java.util.Calendar.DAY_OF_WEEK;
import static java.util.Calendar.HOUR_OF_DAY;
import static java.util.Calendar.MILLISECOND;
import static java.util.Calendar.MINUTE;
import static java.util.Calendar.MONTH;
import static java.util.Calendar.SECOND;
import static java.util.Calendar.YEAR;

import ohos.agp.components.AttrSet;
import ohos.agp.components.Component;
import ohos.agp.components.ComponentContainer;
import ohos.agp.components.LayoutScatter;
import ohos.agp.components.ListContainer;
import ohos.agp.components.RecycleItemProvider;
import ohos.agp.text.Font;
import ohos.agp.window.dialog.ToastDialog;
import ohos.app.Context;

import java.text.DateFormat;
import java.text.DateFormatSymbols;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.Formatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TimeZone;

/**
 * Android component to allow picking a date from a calendar view (a list of months).  Must be
 * initialized after inflation with {@link #init(Date, Date)} and can be customized with any of the
 * {@link FluentInitializer} methods returned.  The currently selected date can be retrieved with
 * {@link #getSelectedDate()}.
 */
public class CalendarPickerView extends ListContainer {
    /**
     * SelectionMode
     */
    public enum SelectionMode {
        /**
         * Only one date will be selectable.  If there is already a selected date and you select a new
         * one, the old date will be unselected.
         */
        SINGLE,
        /* * Multiple dates will be selectable.  Selecting an already-selected date will un-select it. */
        MULTIPLE,
        /**
         * Allows you to select a date range.  Previous selections are cleared when you either:
         * <ul>
         * <li>Have a range selected and select another date (even if it's in the current range).</li>
         * <li>Have one date selected and then select an earlier date.</li>
         * </ul>
         */
        RANGE
    }

    // List of languages that require manually creation of YYYY MMMM date format
    private static final ArrayList<String> EXPLICITLYNUMERICYEARLOCALELANGUAGES =
            new ArrayList<>(Arrays.asList("ar", "my"));

    private final MonthAdapter adapter;
    private final HashMap<String, List<List<MonthCellDescriptor>>> cells = new HashMap<>();
    final MonthView.Listener listener = new CellClickedListener();
    final List<MonthDescriptor> months = new ArrayList<>();
    final List<MonthCellDescriptor> selectedCells = new ArrayList<>();
    final List<MonthCellDescriptor> highlightedCells = new ArrayList<>();
    final List<Calendar> selectedCals = new ArrayList<>();
    final List<Calendar> highlightedCals = new ArrayList<>();
    private Locale locale;
    private TimeZone timeZone;
    private DateFormat weekdayNameFormat;
    private DateFormat fullDateFormat;
    private Calendar minCal;
    private Calendar maxCal;
    private Calendar monthCounter;
    private boolean displayOnly;
    SelectionMode selectionMode;
    Calendar today;
    private int dividerColor;
    private int dayBackgroundResId;
    private int dayTextColorResId;
    private int titleTextStyle;
    private boolean displayHeader;
    private int headerTextColor;
    private boolean displayDayNamesHeaderRow;
    private boolean displayAlwaysDigitNumbers;
    private Font titleTypeface;
    private Font dateTypeface;

    private OnDateSelectedListener dateListener;
    private DateSelectableFilter dateConfiguredListener;
    private OnInvalidDateSelectedListener invalidDateListener = new DefaultOnInvalidDateSelectedListener();
    private CellClickInterceptor cellClickInterceptor;
    private List<CalendarCellDecorator> decorators;
    private DayViewAdapter dayViewAdapter = new DefaultDayViewAdapter();

    private boolean monthsReverseOrder;

    private final StringBuilder monthBuilder = new StringBuilder(50);
    private Formatter monthFormatter;

    public void setDecorators(List<CalendarCellDecorator> decorators) {
        this.decorators = decorators;
        if (null != adapter) {
            adapter.notifyDataChanged();
        }
    }

    public List<CalendarCellDecorator> getDecorators() {
        return decorators;
    }

    public CalendarPickerView(Context context, AttrSet attrs) {
        super(context, attrs);
        dividerColor =
                attrs.getAttr("calendar_divider").isPresent()
                        ? attrs.getAttr("calendar_divider").get().getIntegerValue()
                        : 0x333333;
        dayTextColorResId =
                attrs.getAttr("tsquare_dayTextColor").isPresent()
                        ? attrs.getAttr("tsquare_dayTextColor").get().getIntegerValue()
                        : 0x333333;
        displayHeader =
                attrs.getAttr("tsquare_displayHeader").isPresent()
                        ? attrs.getAttr("tsquare_displayHeader").get().getBoolValue()
                        : true;
        headerTextColor =
                attrs.getAttr("tsquare_headerTextColor").isPresent()
                        ? attrs.getAttr("tsquare_headerTextColor").get().getIntegerValue()
                        : 0x333333;
        displayDayNamesHeaderRow =
                attrs.getAttr("tsquare_displayDayNamesHeaderRow").isPresent()
                        ? attrs.getAttr("tsquare_displayDayNamesHeaderRow").get().getBoolValue()
                        : true;
        displayAlwaysDigitNumbers =
                attrs.getAttr("tsquare_displayAlwaysDigitNumbers").isPresent()
                        ? attrs.getAttr("tsquare_displayAlwaysDigitNumbers").get().getBoolValue()
                        : false;

        adapter = new MonthAdapter();
        timeZone = TimeZone.getDefault();
        locale = Locale.getDefault();
        today = Calendar.getInstance(timeZone, locale);
        minCal = Calendar.getInstance(timeZone, locale);
        maxCal = Calendar.getInstance(timeZone, locale);
        monthCounter = Calendar.getInstance(timeZone, locale);
        weekdayNameFormat = new SimpleDateFormat("EEE", locale);
        weekdayNameFormat.setTimeZone(timeZone);
        fullDateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, locale);
        fullDateFormat.setTimeZone(timeZone);
    }

    /**
     * Both date parameters must be non-null and their {@link Date#getTime()} must not return 0. Time
     * of day will be ignored.  For instance, if you pass in {@code minDate} as 11/16/2012 5:15pm and
     * {@code maxDate} as 11/16/2013 4:30am, 11/16/2012 will be the first selectable date and
     * 11/15/2013 will be the last selectable date ({@code maxDate} is exclusive).
     * <p>
     * This will implicitly set the {@link SelectionMode} to {@link SelectionMode#SINGLE}.  If you
     * want a different selection mode, use {@link FluentInitializer#inMode(SelectionMode)} on the
     * {@link FluentInitializer} this method returns.
     * <p>
     * The calendar will be constructed using the given time zone and the given locale. This means
     * that all dates will be in given time zone, all names (months, days) will be in the language
     * of the locale and the weeks start with the day specified by the locale.
     *
     * @param minDate  Earliest selectable date, inclusive.  Must be earlier than {@code maxDate}.
     * @param maxDate  Latest selectable date, exclusive.  Must be later than {@code minDate}.
     * @param locale   locale
     * @param timeZone timeZone
     * @return FluentInitializer
     */
    public FluentInitializer init(Date minDate, Date maxDate, TimeZone timeZone, Locale locale) {
        setThrowException(minDate, maxDate, timeZone, locale);
        // Make sure that all calendar instances use the same time zone and locale.
        this.timeZone = timeZone;
        this.locale = locale;
        today = Calendar.getInstance(timeZone, locale);
        minCal = Calendar.getInstance(timeZone, locale);
        maxCal = Calendar.getInstance(timeZone, locale);
        monthCounter = Calendar.getInstance(timeZone, locale);
        for (MonthDescriptor month : months) {
            month.setLabel(formatMonthDate(month.getDate()));
        }
        weekdayNameFormat = new SimpleDateFormat("EEE", locale);
        weekdayNameFormat.setTimeZone(timeZone);
        fullDateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, locale);
        fullDateFormat.setTimeZone(timeZone);
        monthFormatter = new Formatter(monthBuilder, locale);

        this.selectionMode = SelectionMode.SINGLE;
        clearData();
        minCal.setTime(minDate);
        maxCal.setTime(maxDate);
        setMidnight(minCal);
        setMidnight(maxCal);
        displayOnly = false;
        maxCal.add(MINUTE, -1);
        // Now iterate between minCal and maxCal and build up our list of months to show.
        monthCounter.setTime(minCal.getTime());
        final int maxMonth = maxCal.get(MONTH);
        final int maxYear = maxCal.get(YEAR);
        while ((monthCounter.get(MONTH) <= maxMonth // Up to, including the month.
                || monthCounter.get(YEAR) < maxYear) // Up to the year.
                && monthCounter.get(YEAR) < maxYear + 1) { // But not > next yr.
            Date date = monthCounter.getTime();
            MonthDescriptor month = new MonthDescriptor(monthCounter.get(MONTH), monthCounter.get(YEAR), date, formatMonthDate(date));
            cells.put(monthKey(month), getMonthCells(month, monthCounter));
            Logr.debug("Adding month %s", month);
            months.add(month);
            monthCounter.add(MONTH, 1);
        }
        validateAndUpdate();
        return new FluentInitializer();
    }

    private void clearData() {
        // Clear out any previously-selected dates/cells.
        selectedCals.clear();
        selectedCells.clear();
        highlightedCals.clear();
        highlightedCells.clear();

        // Clear previous state.
        cells.clear();
        months.clear();
    }

    private void setThrowException(Date minDate, Date maxDate, TimeZone timeZone, Locale locale) {
        if (minDate == null || maxDate == null) {
            throw new IllegalArgumentException("minDate and maxDate must be non-null.  " + dbg(minDate, maxDate));
        }
        if (minDate.after(maxDate)) {
            throw new IllegalArgumentException("minDate must be before maxDate.  " + dbg(minDate, maxDate));
        }
        if (locale == null) {
            throw new IllegalArgumentException("Locale is null.");
        }
        if (timeZone == null) {
            throw new IllegalArgumentException("Time zone is null.");
        }
    }

    /**
     * Both date parameters must be non-null and their {@link Date#getTime()} must not return 0. Time
     * of day will be ignored.  For instance, if you pass in {@code minDate} as 11/16/2012 5:15pm and
     * {@code maxDate} as 11/16/2013 4:30am, 11/16/2012 will be the first selectable date and
     * 11/15/2013 will be the last selectable date ({@code maxDate} is exclusive).
     * <p>
     * This will implicitly set the {@link SelectionMode} to {@link SelectionMode#SINGLE}.  If you
     * want a different selection mode, use {@link FluentInitializer#inMode(SelectionMode)} on the
     * {@link FluentInitializer} this method returns.
     * <p>
     * The calendar will be constructed using the default locale as returned by
     * {@link Locale#getDefault()} and default time zone as returned by
     * {@link TimeZone#getDefault()}. If you wish the calendar to be constructed using a
     * different locale or time zone, use
     * {@link #init(Date, Date, Locale)},
     * {@link #init(Date, Date, TimeZone)} or
     * {@link #init(Date, Date, TimeZone, Locale)}.
     *
     * @param minDate Earliest selectable date, inclusive.  Must be earlier than {@code maxDate}.
     * @param maxDate Latest selectable date, exclusive.  Must be later than {@code minDate}.
     * @return FluentInitializer
     */
    public FluentInitializer init(Date minDate, Date maxDate) {
        return init(minDate, maxDate, TimeZone.getDefault(), Locale.getDefault());
    }

    /**
     * Both date parameters must be non-null and their {@link Date#getTime()} must not return 0. Time
     * of day will be ignored.  For instance, if you pass in {@code minDate} as 11/16/2012 5:15pm and
     * {@code maxDate} as 11/16/2013 4:30am, 11/16/2012 will be the first selectable date and
     * 11/15/2013 will be the last selectable date ({@code maxDate} is exclusive).
     * <p>
     * This will implicitly set the {@link SelectionMode} to {@link SelectionMode#SINGLE}.  If you
     * want a different selection mode, use {@link FluentInitializer#inMode(SelectionMode)} on the
     * {@link FluentInitializer} this method returns.
     * <p>
     * The calendar will be constructed using the given time zone and the default locale as returned
     * by {@link Locale#getDefault()}. This means that all dates will be in given time zone.
     * If you wish the calendar to be constructed using a different locale, use
     * {@link #init(Date, Date, Locale)} or
     * {@link #init(Date, Date, TimeZone, Locale)}.
     *
     * @param minDate  Earliest selectable date, inclusive.  Must be earlier than {@code maxDate}.
     * @param maxDate  Latest selectable date, exclusive.  Must be later than {@code minDate}.
     * @param timeZone timeZone
     * @return FluentInitializer
     */
    public FluentInitializer init(Date minDate, Date maxDate, TimeZone timeZone) {
        return init(minDate, maxDate, timeZone, Locale.getDefault());
    }

    /**
     * Both date parameters must be non-null and their {@link Date#getTime()} must not return 0. Time
     * of day will be ignored.  For instance, if you pass in {@code minDate} as 11/16/2012 5:15pm and
     * {@code maxDate} as 11/16/2013 4:30am, 11/16/2012 will be the first selectable date and
     * 11/15/2013 will be the last selectable date ({@code maxDate} is exclusive).
     * <p>
     * This will implicitly set the {@link SelectionMode} to {@link SelectionMode#SINGLE}.  If you
     * want a different selection mode, use {@link FluentInitializer#inMode(SelectionMode)} on the
     * {@link FluentInitializer} this method returns.
     * <p>
     * The calendar will be constructed using the given locale. This means that all names
     * (months, days) will be in the language of the locale and the weeks start with the day
     * specified by the locale.
     * <p>
     * The calendar will be constructed using the given locale and the default time zone as returned
     * by {@link TimeZone#getDefault()}. This means that all names (months, days) will be
     * in the language of the locale and the weeks start with the day specified by the locale.
     * If you wish the calendar to be constructed using a different time zone, use
     * {@link #init(Date, Date, TimeZone)} or
     * {@link #init(Date, Date, TimeZone, Locale)}.
     *
     * @param minDate Earliest selectable date, inclusive.  Must be earlier than {@code maxDate}.
     * @param maxDate Latest selectable date, exclusive.  Must be later than {@code minDate}.
     * @param locale  locale
     * @return FluentInitializer
     */
    public FluentInitializer init(Date minDate, Date maxDate, Locale locale) {
        return init(minDate, maxDate, TimeZone.getDefault(), locale);
    }

    /**
     * FluentInitializer
     */
    public class FluentInitializer {
        /**
         * Override the {@link SelectionMode} from the default ({@link SelectionMode#SINGLE}).
         * @param mode mode
         * @return FluentInitializer
         */
        public FluentInitializer inMode(SelectionMode mode) {
            selectionMode = mode;
            validateAndUpdate();
            return this;
        }

        /**
         * Set an initially-selected date.  The calendar will scroll to that date if it's not already
         * visible.
         *
         * @param selectedDates selectedDates
         * @return FluentInitializer
         */
        public FluentInitializer withSelectedDate(Date selectedDates) {
            return withSelectedDates(Collections.singletonList(selectedDates));
        }

        /**
         * Set multiple selected dates.  This will throw an {@link IllegalArgumentException} if you
         * pass in multiple dates and haven't already called {@link #inMode(SelectionMode)}.
         *
         * @param selectedDates selectedDates
         * @return FluentInitializer
         */
        public FluentInitializer withSelectedDates(Collection<Date> selectedDates) {
            if (selectionMode == SelectionMode.SINGLE && selectedDates.size() > 1) {
                throw new IllegalArgumentException("SINGLE mode can't be used with multiple selectedDates");
            }
            if (selectionMode == SelectionMode.RANGE && selectedDates.size() > 2) {
                throw new IllegalArgumentException(
                        "RANGE mode only allows two selectedDates.  You tried to pass " + selectedDates.size());
            }
            if (selectedDates != null) {
                for (Date date : selectedDates) {
                    selectDate(date);
                }
            }
            scrollToSelectedDates();

            validateAndUpdate();
            return this;
        }

        /**
         * withHighlightedDates
         *
         * @param dates dates
         * @return FluentInitializer
         */
        public FluentInitializer withHighlightedDates(Collection<Date> dates) {
            highlightDates(dates);
            return this;
        }

        /**
         * withHighlightedDate
         *
         * @param date date
         * @return FluentInitializer
         */
        public FluentInitializer withHighlightedDate(Date date) {
            return withHighlightedDates(Collections.singletonList(date));
        }

        /**
         * setShortWeekdays
         *
         * @param newShortWeekdays newShortWeekdays
         * @return FluentInitializer
         */
        public FluentInitializer setShortWeekdays(String[] newShortWeekdays) {
            DateFormatSymbols symbols = new DateFormatSymbols(locale);
            symbols.setShortWeekdays(newShortWeekdays);
            weekdayNameFormat = new SimpleDateFormat("EEE", symbols);
            return this;
        }

        /**
         * displayOnly
         *
         * @return FluentInitializer
         */
        public FluentInitializer displayOnly() {
            displayOnly = true;
            return this;
        }

        /**
         * withMonthsReverseOrder
         *
         * @param monthsRevOrder monthsRevOrder
         * @return FluentInitializer
         */
        public FluentInitializer withMonthsReverseOrder(boolean monthsRevOrder) {
            monthsReverseOrder = monthsRevOrder;
            return this;
        }
    }

    private void validateAndUpdate() {
        if (adapter != null) {
            adapter.notifyDataChanged();
        }
    }

    private void scrollToSelectedMonth(final int selectedIndex) {
        scrollToSelectedMonth(selectedIndex, false);
    }

    private void scrollToSelectedMonth(final int selectedIndex, final boolean smoothScroll) {
        if (!smoothScroll) {
            setSelectedItemIndex(selectedIndex);
        }
    }

    private void scrollToSelectedDates() {
        Integer selectedIndex = null;
        Integer todayIndex = null;
        Calendar today = Calendar.getInstance(timeZone, locale);
        for (int c = 0; c < months.size(); c++) {
            MonthDescriptor monthdes = months.get(c);
            if (selectedIndex == null) {
                for (Calendar selectedCal : selectedCals) {
                    if (sameMonth(selectedCal, monthdes)) {
                        selectedIndex = c;
                        break;
                    }
                }
                if (selectedIndex == null && todayIndex == null && sameMonth(today, monthdes)) {
                    todayIndex = c;
                }
            }
        }
        if (selectedIndex != null) {
            scrollToSelectedMonth(selectedIndex);
        } else if (todayIndex != null) {
            scrollToSelectedMonth(todayIndex);
        }
    }

    /**
     * scrollToDate
     *
     * @param date date
     * @return boolean
     */
    public boolean scrollToDate(Date date) {
        Integer selectedIndex = null;

        Calendar cal = Calendar.getInstance(timeZone, locale);
        cal.setTime(date);
        for (int c = 0; c < months.size(); c++) {
            MonthDescriptor month = months.get(c);
            if (sameMonth(cal, month)) {
                selectedIndex = c;
                break;
            }
        }
        if (selectedIndex != null) {
            scrollToSelectedMonth(selectedIndex);
            return true;
        }
        return false;
    }

    /**
     * This method should only be called if the calendar is contained in a dialog, and it should only
     * be called once, right after the dialog is shown (using
     */
    public void fixDialogDimens() {
        // Fix the layout height/width after the dialog has been shown.
        getLayoutConfig().width = getHeight();
        getLayoutConfig().height = getWidth();
        // Post this runnable so it runs _after_ the dimen changes have been applied/re-measured.
        scrollToSelectedDates();
    }

    /**
     * Set the typeface to be used for month titles.
     *
     * @param titleTypeface titleTypeface
     */
    public void setTitleTypeface(Font titleTypeface) {
        this.titleTypeface = titleTypeface;
        validateAndUpdate();
    }

    /**
     * Sets the typeface to be used within the date grid.
     *
     * @param dateTypeface dateTypeface
     */
    public void setDateTypeface(Font dateTypeface) {
        this.dateTypeface = dateTypeface;
        validateAndUpdate();
    }

    /**
     * Sets the typeface to be used for all text within this calendar.
     *
     * @param typeface typeface
     */
    public void setTypeface(Font typeface) {
        setTitleTypeface(typeface);
        setDateTypeface(typeface);
    }

    /**
     * This method should only be called if the calendar is contained in a dialog, and it should only
     * be called when the screen has been rotated and the dialog should be re-measured.
     */
    public void unfixDialogDimens() {
        Logr.debug("Reset the fixed dimensions to allow for re-measurement");
        // Fix the layout height/width after the dialog has been shown.
        getLayoutConfig().width = LayoutConfig.MATCH_PARENT;
        getLayoutConfig().height = LayoutConfig.MATCH_PARENT;
        invalidate();
    }

    /**
     * getSelectedDate
     *
     * @return Date
     */
    public Date getSelectedDate() {
        return (selectedCals.size() > 0 ? selectedCals.get(0).getTime() : null);
    }

    /**
     * getSelectedDates
     *
     * @return List
     */
    public List<Date> getSelectedDates() {
        List<Date> selectedDates = new ArrayList<>();
        for (MonthCellDescriptor cal : selectedCells) {
            selectedDates.add(cal.getDate());
        }
        Collections.sort(selectedDates);
        return selectedDates;
    }

    /**
     * Returns a string summarizing what the client sent us for init() params.
     *
     * @param maxDate maxDate
     * @param minDate minDate
     * @return str
     */
    private static String dbg(Date minDate, Date maxDate) {
        return "minDate: " + minDate + "maxDate: " + maxDate;
    }

    /**
     * Clears out the hours/minutes/seconds/millis of a Calendar.
     *
     * @param cal cal
     */
    static void setMidnight(Calendar cal) {
        cal.set(HOUR_OF_DAY, 0);
        cal.set(MINUTE, 0);
        cal.set(SECOND, 0);
        cal.set(MILLISECOND, 0);
    }

    private class CellClickedListener implements MonthView.Listener {
        @Override
        public void handleClick(MonthCellDescriptor cell) {
            Date clickedDate = cell.getDate();

            if (cellClickInterceptor != null && cellClickInterceptor.onCellClicked(clickedDate)) {
                return;
            }
            if (!betweenDates(clickedDate, minCal, maxCal) || !isDateSelectable(clickedDate)) {
                if (invalidDateListener != null) {
                    invalidDateListener.onInvalidDateSelected(clickedDate);
                }
            } else {
                boolean wasSelected = doSelectDate(clickedDate, cell);

                if (dateListener != null) {
                    if (wasSelected) {
                        dateListener.onDateSelected(clickedDate);
                    } else {
                        dateListener.onDateUnselected(clickedDate);
                    }
                }
            }
        }
    }

    /**
     * Select a new date.  Respects the {@link SelectionMode} this CalendarPickerView is configured
     * with: if you are in {@link SelectionMode#SINGLE}, the previously selected date will be
     * un-selected.  In {@link SelectionMode#MULTIPLE}, the new date will be added to the list of
     * selected dates.
     * <p>
     * If the selection was made (selectable date, in range), the view will scroll to the newly
     * selected date if it's not already visible.
     *
     * @param date date
     * @return - whether we were able to set the date
     */
    public boolean selectDate(Date date) {
        return selectDate(date, false);
    }

    /**
     * Select a new date.  Respects the {@link SelectionMode} this CalendarPickerView is configured
     * with: if you are in {@link SelectionMode#SINGLE}, the previously selected date will be
     * un-selected.  In {@link SelectionMode#MULTIPLE}, the new date will be added to the list of
     * selected dates.
     * <p>
     * If the selection was made (selectable date, in range), the view will scroll to the newly
     * selected date if it's not already visible.
     *
     * @param date         date
     * @param smoothScroll smoothScroll
     * @return - whether we were able to set the date
     */
    public boolean selectDate(Date date, boolean smoothScroll) {
        validateDate(date);

        MonthCellWithMonthIndex monthCellWithMonthIndex = getMonthCellWithIndexByDate(date);
        if (monthCellWithMonthIndex == null || !isDateSelectable(date)) {
            return false;
        }
        boolean wasSelected = doSelectDate(date, monthCellWithMonthIndex.cell);
        if (wasSelected) {
            scrollToSelectedMonth(monthCellWithMonthIndex.monthIndex, smoothScroll);
        }
        return wasSelected;
    }

    /**
     * formatMonthDate
     *
     * @param date date
     * @return string
     */
    private String formatMonthDate(Date date) {

        // Save default Locale
        Locale defaultLocale = Locale.getDefault();

        // Set new default Locale, the reason to do that is DateUtils.formatDateTime uses
        // internally this method DateIntervalFormat.formatDateRange to format the date. And this
        // method uses the default locale.
        //
        // More details about the methods:
        // - DateUtils.formatDateTime: https://goo.gl/3YW52Q
        // - DateIntervalFormat.formatDateRange: https://goo.gl/RRmfK7
        Locale.setDefault(locale);

        String dateFormatted = "";
        if (displayAlwaysDigitNumbers && EXPLICITLYNUMERICYEARLOCALELANGUAGES.contains(locale.getLanguage())) {
            StringBuilder sb = new StringBuilder();
            SimpleDateFormat sdfMonth = new SimpleDateFormat("MMMM", locale);
            SimpleDateFormat sdfYear = new SimpleDateFormat("yyyy", Locale.ENGLISH);
            dateFormatted =
                    sb.append(sdfMonth.format(date.getTime()))
                            .append(" ")
                            .append(sdfYear.format(date.getTime()))
                            .toString();
        }
        // Call setLength(0) on StringBuilder passed to the Formatter constructor to not accumulate
        // the results
        monthBuilder.setLength(0);

        // Restore default Locale to avoid generating any side effects
        Locale.setDefault(defaultLocale);

        return dateFormatted;
    }

    private void validateDate(Date date) {
        if (date == null) {
            throw new IllegalArgumentException("Selected date must be non-null.");
        }
        if (date.before(minCal.getTime()) || date.after(maxCal.getTime())) {
            throw new IllegalArgumentException(
                    String.format(
                            "SelectedDate must be between minDate and maxDate."
                                    + "%nminDate: %s%nmaxDate: %s%nselectedDate: %s",
                            minCal.getTime(), maxCal.getTime(), date));
        }
    }

    private boolean doSelectDate(Date date, MonthCellDescriptor cell) {
        Calendar newlySelectedCal = Calendar.getInstance(timeZone, locale);
        newlySelectedCal.setTime(date);
        // Sanitize input: clear out the hours/minutes/seconds/millis.
        setMidnight(newlySelectedCal);

        // Clear any remaining range state.
        for (MonthCellDescriptor selectedCell : selectedCells) {
            selectedCell.setRangeState(RangeState.NONE);
        }

        switch (selectionMode) {
            case RANGE:
                if (selectedCals.size() > 1) {
                    // We've already got a range selected: clear the old one.
                    clearOldSelections();
                } else if (selectedCals.size() == 1 && newlySelectedCal.before(selectedCals.get(0))) {
                    // We're moving the start of the range back in time: clear the old start date.
                    clearOldSelections();
                }
                break;

            case MULTIPLE:
                date = applyMultiSelect(date, newlySelectedCal);
                break;

            case SINGLE:
                clearOldSelections();
                break;
            default:
                throw new IllegalStateException("Unknown selectionMode " + selectionMode);
        }

        cellSelectAdd(newlySelectedCal, date, cell);
        // Update the adapter.
        validateAndUpdate();
        return date != null;
    }

    private void cellSelectAdd(Calendar newlySelectedCal,Date date, MonthCellDescriptor cell) {
        if (date != null) {
            // Select a new cell.
            if (selectedCells.size() == 0 || !selectedCells.get(0).equals(cell)) {
                selectedCells.add(cell);
                cell.setSelected(true);
            }
            selectedCals.add(newlySelectedCal);

            if (selectionMode == SelectionMode.RANGE && selectedCells.size() > 1) {
                // Select all days in between start and end.
                Date start = selectedCells.get(0).getDate();
                Date end = selectedCells.get(1).getDate();
                selectedCells.get(0).setRangeState(RangeState.FIRST);
                selectedCells.get(1).setRangeState(RangeState.LAST);

                int startMonthIndex = getIndexOfKey(monthKey(selectedCals.get(0)));
                int endMonthIndex = getIndexOfKey(monthKey(selectedCals.get(1)));
                for (int monthIndex = startMonthIndex; monthIndex <= endMonthIndex; monthIndex++) {
                    List<List<MonthCellDescriptor>> month = getValueByIndex(monthIndex);
                    for (List<MonthCellDescriptor> week : month) {
                        for (MonthCellDescriptor singleCell : week) {
                            if (singleCell.getDate().after(start)
                                    && singleCell.getDate().before(end)
                                    && singleCell.isSelectable()) {
                                singleCell.setSelected(true);
                                singleCell.setRangeState(RangeState.MIDDLE);
                                selectedCells.add(singleCell);
                            }
                        }
                    }
                }
            }
        }
    }

    private int getIndexOfKey(String key) {
        if (cells == null || cells.size() == 0) {
            return 0;
        }
        int index = 0;

        Set<String> keys = cells.keySet();
        for (String keyItem : keys) {
            if (key.equals(keyItem)) {
                return index;
            }
            index++;
        }
        return index;
    }

    private List<List<MonthCellDescriptor>> getValueByIndex(int keyIndex) {
        if (cells == null || cells.size() == 0) {
            return null;
        }
        int index = 0;
        Set<String> keys = cells.keySet();
        for (String keyItem : keys) {
            if (index == keyIndex) {
                return cells.get(keyItem);
            }
            index++;
        }
        return null;
    }

    private String monthKey(Calendar cal) {
        return cal.get(YEAR) + "-" + cal.get(MONTH);
    }

    private String monthKey(MonthDescriptor month) {
        return month.getYear() + "-" + month.getMonth();
    }

    private void clearOldSelections() {
        for (MonthCellDescriptor selectedCell : selectedCells) {
            // De-select the currently-selected cell.
            selectedCell.setSelected(false);

            if (dateListener != null) {
                Date selectedDate = selectedCell.getDate();

                if (selectionMode == SelectionMode.RANGE) {
                    int index = selectedCells.indexOf(selectedCell);
                    if (index == 0 || index == selectedCells.size() - 1) {
                        dateListener.onDateUnselected(selectedDate);
                    }
                } else {
                    dateListener.onDateUnselected(selectedDate);
                }
            }
        }
        selectedCells.clear();
        selectedCals.clear();
    }

    private Date applyMultiSelect(Date date, Calendar selectedCal) {
        for (MonthCellDescriptor selectedCell : selectedCells) {
            if (selectedCell.getDate().equals(date)) {
                // De-select the currently-selected cell.
                selectedCell.setSelected(false);
                removeSele(selectedCell);
                date = null;
                break;
            }
        }
        for (Calendar cal : selectedCals) {
            if (sameDate(cal, selectedCal)) {
                removeCal(cal);
                break;
            }
        }
        return date;
    }

    private void removeSele(MonthCellDescriptor selectedCell) {
        selectedCells.remove(selectedCell);
    }

    private void removeCal(Calendar cal) {
        selectedCals.remove(cal);
    }

    /**
     * highlightDates
     *
     * @param dates dates
     */
    public void highlightDates(Collection<Date> dates) {
        for (Date date : dates) {
            validateDate(date);

            MonthCellWithMonthIndex monthCellWithMonthIndex = getMonthCellWithIndexByDate(date);
            if (monthCellWithMonthIndex != null) {
                Calendar newlyHighlightedCal = Calendar.getInstance(timeZone, locale);
                newlyHighlightedCal.setTime(date);
                MonthCellDescriptor cell = monthCellWithMonthIndex.cell;

                highlightedCells.add(cell);
                highlightedCals.add(newlyHighlightedCal);
                cell.setHighlighted(true);
            }
        }

        validateAndUpdate();
    }

    /**
     * clearSelectedDates
     */
    public void clearSelectedDates() {
        for (MonthCellDescriptor selectedCell : selectedCells) {
            selectedCell.setRangeState(RangeState.NONE);
        }

        clearOldSelections();
        validateAndUpdate();
    }

    /**
     * clearHighlightedDates
     */
    public void clearHighlightedDates() {
        for (MonthCellDescriptor cal : highlightedCells) {
            cal.setHighlighted(false);
        }
        highlightedCells.clear();
        highlightedCals.clear();

        validateAndUpdate();
    }

    /* * Hold a cell with a month-index. */
    private static class MonthCellWithMonthIndex {
        MonthCellDescriptor cell;
        int monthIndex;

        MonthCellWithMonthIndex(MonthCellDescriptor cell, int monthIndex) {
            this.cell = cell;
            this.monthIndex = monthIndex;
        }
    }

    /* * Return cell and month-index (for scrolling) for a given Date. */
    private MonthCellWithMonthIndex getMonthCellWithIndexByDate(Date date) {
        Calendar searchCal = Calendar.getInstance(timeZone, locale);
        searchCal.setTime(date);
        String monthKey = monthKey(searchCal);
        Calendar actCal = Calendar.getInstance(timeZone, locale);

        int index = getIndexOfKey(monthKey);
        List<List<MonthCellDescriptor>> monthCells = cells.get(monthKey);
        for (List<MonthCellDescriptor> weekCells : monthCells) {
            for (MonthCellDescriptor actCell : weekCells) {
                actCal.setTime(actCell.getDate());
                if (sameDate(actCal, searchCal) && actCell.isSelectable()) {
                    return new MonthCellWithMonthIndex(actCell, index);
                }
            }
        }
        return null;
    }

    private class MonthAdapter extends RecycleItemProvider {
        private final LayoutScatter inflater;

        private MonthAdapter() {
            inflater = LayoutScatter.getInstance(getContext());
        }

        @Override
        public int getCount() {
            return months.size();
        }

        @Override
        public Object getItem(int position) {
            return months.get(position);
        }

        @Override
        public long getItemId(int position) {
            return position;
        }

        @Override
        public Component getComponent(int position, Component component, ComponentContainer componentContainer) {
            MonthView monthView1 = (MonthView) component;
            if (monthView1 == null //
                    || !monthView1.getTag().equals(dayViewAdapter.getClass())) {
                monthView1 =
                        MonthView.create(
                                componentContainer,
                                inflater,
                                weekdayNameFormat,
                                listener,
                                today,
                                dividerColor,
                                dayBackgroundResId,
                                dayTextColorResId,
                                titleTextStyle,
                                displayHeader,
                                headerTextColor,
                                displayDayNamesHeaderRow,
                                displayAlwaysDigitNumbers,
                                decorators,
                                locale,
                                dayViewAdapter);
                monthView1.setTag(dayViewAdapter.getClass());
            } else {
                monthView1.setDecorators(decorators);
            }
            if (monthsReverseOrder) {
                position = months.size() - position - 1;
            }
            monthView1.init(months.get(position), getValueByIndex(position), displayOnly, titleTypeface, dateTypeface);
            return monthView1;
        }
    }

    List<List<MonthCellDescriptor>> getMonthCells(MonthDescriptor month, Calendar startCal) {
        Calendar cal = Calendar.getInstance(timeZone, locale);
        cal.setTime(startCal.getTime());
        List<List<MonthCellDescriptor>> cells = new ArrayList<>();
        cal.set(DAY_OF_MONTH, 1);
        int firstDayOfWeek = cal.get(DAY_OF_WEEK);
        int offset = cal.getFirstDayOfWeek() - firstDayOfWeek;
        if (offset > 0) {
            offset -= 7;
        }
        cal.add(Calendar.DATE, offset);

        Calendar minSelectedCal = minDate(selectedCals);
        Calendar maxSelectedCal = maxDate(selectedCals);

        while ((cal.get(MONTH) < month.getMonth() + 1 || cal.get(YEAR) < month.getYear()) //
                && cal.get(YEAR) <= month.getYear()) {
            Logr.debug("Building week row starting at %s", cal.getTime());
            List<MonthCellDescriptor> weekCells = new ArrayList<>();
            cells.add(weekCells);
            for (int c = 0; c < 7; c++) {
                Date date = cal.getTime();
                boolean isCurrentMonth = cal.get(MONTH) == month.getMonth();
                boolean isSelected = isCurrentMonth && containsDate(selectedCals, cal);
                boolean isSelectable = isCurrentMonth && betweenDates(cal, minCal, maxCal) && isDateSelectable(date);
                boolean isToday = sameDate(cal, today);
                boolean isHighlighted = containsDate(highlightedCals, cal);
                int value = cal.get(DAY_OF_MONTH);

                RangeState rangeState = RangeState.NONE;
                if (selectedCals.size() > 1) {
                    if (sameDate(minSelectedCal, cal)) {
                        rangeState = RangeState.FIRST;
                    } else if (sameDate(maxDate(selectedCals), cal)) {
                        rangeState = RangeState.LAST;
                    } else if (betweenDates(cal, minSelectedCal, maxSelectedCal)) {
                        rangeState = RangeState.MIDDLE;
                    }
                }

                weekCells.add(
                        new MonthCellDescriptor(
                                date,
                                isCurrentMonth,
                                isSelectable,
                                isSelected,
                                isToday,
                                isHighlighted,
                                value,
                                rangeState));
                cal.add(DATE, 1);
            }
        }
        return cells;
    }

    private boolean containsDate(List<Calendar> selectedCals, Date date) {
        Calendar cal = Calendar.getInstance(timeZone, locale);
        cal.setTime(date);
        return containsDate(selectedCals, cal);
    }

    private static boolean containsDate(List<Calendar> selectedCals, Calendar cal) {
        for (Calendar selectedCal : selectedCals) {
            if (sameDate(cal, selectedCal)) {
                return true;
            }
        }
        return false;
    }

    private static Calendar minDate(List<Calendar> selectedCals) {
        if (selectedCals == null || selectedCals.size() == 0) {
            return null;
        }
        Collections.sort(selectedCals);
        return selectedCals.get(0);
    }

    private static Calendar maxDate(List<Calendar> selectedCals) {
        if (selectedCals == null || selectedCals.size() == 0) {
            return null;
        }
        Collections.sort(selectedCals);
        return selectedCals.get(selectedCals.size() - 1);
    }

    private static boolean sameDate(Calendar cal, Calendar selectedDate) {
        return cal.get(MONTH) == selectedDate.get(MONTH)
                && cal.get(YEAR) == selectedDate.get(YEAR)
                && cal.get(DAY_OF_MONTH) == selectedDate.get(DAY_OF_MONTH);
    }

    private static boolean betweenDates(Calendar cal, Calendar minCal, Calendar maxCal) {
        final Date date = cal.getTime();
        return betweenDates(date, minCal, maxCal);
    }

    static boolean betweenDates(Date date, Calendar minCal, Calendar maxCal) {
        final Date min = minCal.getTime();
        return (date.equals(min) || date.after(min))
                && date.before(maxCal.getTime());
    }

    private static boolean sameMonth(Calendar cal, MonthDescriptor month) {
        return (cal.get(MONTH) == month.getMonth() && cal.get(YEAR) == month.getYear());
    }

    private boolean isDateSelectable(Date date) {
        return dateConfiguredListener == null || dateConfiguredListener.isDateSelectable(date);
    }

    /**
     * setOnDateSelectedListener
     *
     * @param listener listener
     */
    public void setOnDateSelectedListener(OnDateSelectedListener listener) {
        dateListener = listener;
    }

    /**
     * Set a listener to react to user selection of a disabled date.
     *
     * @param listener the listener to set, or null for no reaction
     */
    public void setOnInvalidDateSelectedListener(OnInvalidDateSelectedListener listener) {
        invalidDateListener = listener;
    }

    /**
     * Set a listener used to discriminate between selectable and unselectable dates. Set this to
     * disable arbitrary dates as they are rendered.
     * <p>
     * Important: set this before you call {@link #init(Date, Date)} methods.  If called afterwards,
     * it will not be consistently applied.
     *
     * @param listener listener
     */
    public void setDateSelectableFilter(DateSelectableFilter listener) {
        dateConfiguredListener = listener;
    }

    /**
     * Set an adapter used to initialize {@link CalendarCellView} with custom layout.
     * <p>
     * Important: set this before you call {@link #init(Date, Date)} methods.  If called afterwards,
     * it will not be consistently applied.
     *
     * @param dayViewAdapter dayViewAdapter
     */
    public void setCustomDayView(DayViewAdapter dayViewAdapter) {
        this.dayViewAdapter = dayViewAdapter;
        if (null != adapter) {
            adapter.notifyDataChanged();
        }
    }

    /**
     * Set a listener to intercept clicks on calendar cells.
     *
     * @param listener listener
     */
    public void setCellClickInterceptor(CellClickInterceptor listener) {
        cellClickInterceptor = listener;
    }

    /**
     * Interface to be notified when a new date is selected or unselected. This will only be called
     * when the user initiates the date selection.  If you call {@link #selectDate(Date)} this
     * listener will not be notified.
     *
     * @see #setOnDateSelectedListener(OnDateSelectedListener)
     */
    public interface OnDateSelectedListener {
        void onDateSelected(Date date);

        void onDateUnselected(Date date);
    }

    /**
     * Interface to be notified when an invalid date is selected by the user. This will only be
     * called when the user initiates the date selection. If you call {@link #selectDate(Date)} this
     * listener will not be notified.
     *
     * @see #setOnInvalidDateSelectedListener(OnInvalidDateSelectedListener)
     */
    public interface OnInvalidDateSelectedListener {
        void onInvalidDateSelected(Date date);
    }

    /**
     * Interface used for determining the selectability of a date cell when it is configured for
     * display on the calendar.
     *
     * @see #setDateSelectableFilter(DateSelectableFilter)
     */
    public interface DateSelectableFilter {
        boolean isDateSelectable(Date date);
    }

    /**
     * Interface to be notified when a cell is clicked and possibly intercept the click.  Return true
     * to intercept the click and prevent any selections from changing.
     *
     * @see #setCellClickInterceptor(CellClickInterceptor)
     */
    public interface CellClickInterceptor {
        boolean onCellClicked(Date date);
    }

    private class DefaultOnInvalidDateSelectedListener implements OnInvalidDateSelectedListener {
        @Override
        public void onInvalidDateSelected(Date date) {
            String errMessage =
                    String.format(
                            "Date must be between %1$s and %2$s.",
                            fullDateFormat.format(minCal.getTime()), fullDateFormat.format(maxCal.getTime()));
            new ToastDialog(getContext()).setText(errMessage).setDuration(2000).show();
        }
    }
}
